在我们的日常开发中,总会有时候需要对一些变量做并发读写,比如 web
应用在同时接到多个请求之后,
需要对一些资源做初始化,而这些资源可能是只需要初始化一次的,而不是每一个
http 请求都初始化,
在这种情况下,我们需要限制只能一个协程来做初始化的操作,比如初始化数据库连接等,
这个时候,我们就需要有一种机制,可以限制只有一个协程来执行这些初始化的代码。
在 go
语言中,我们可以使用互斥锁(Mutex
)来实现这种功能。
互斥锁的定义
这里引用一下维基百科的定义:
互斥锁(Mutual exclusion,缩写
Mutex
)是一种用于多线程编程中,防止两个线程同时对同一公共资源
(比如全局变量)进行读写的机制。该目的通过将代码切片成一个一个的临界区域(critical
section)达成。
临街区域指的是一块对公共资源进行访问的代码,并非一种机制或是算法。
互斥,顾名思义,也就是只有一个线程能持有锁。当然,在 go 中,是只有一个协程能持有锁。
下面是一个简单的例子:
1 | var sum int // 和 |
上面的例子中,我们定义了一个全局变量
sum
,用于存储和,然后定义了一个互斥锁 mu
, 在
add()
函数中,我们使用 mu.Lock()
来加锁,然后对 sum
进行加 1 操作, 最后使用
mu.Unlock()
来解锁,这样就保证了在任意时刻,只有一个协程能够对 sum
进行加 1 操作, 从而保证了在并发执行 add()
操作的时候
sum
的值是正确的。
上面这个例子,在我之前的文章中已经作为例子出现过很多次了,这里不再赘述了。
go Mutex 的基本用法
Mutex
我们一般只会用到它的两个方法:
Lock
:获取互斥锁。(只会有一个协程可以获取到锁,通常用在临界区开始的地方。)Unlock
: 释放互斥锁。(释放获取到的锁,通常用在临界区结束的地方。)
Mutex
的模型可以用下图表示:
说明:
- 同一时刻只能有一个协程获取到
Mutex
的使用权,其他协程需要排队等待(也就是上图的G1->G2->Gn
)。 - 拥有锁的协程从临界区退出的时候需要使用
Unlock
来释放锁,这个时候等待队列的下一个协程可以获取到锁(实际实现比这里说的复杂很多,后面会细说),从而进入临界区。 - 等待的协程会在
Lock
调用处阻塞,Unlock
的时候会使得一个等待的协程解除阻塞的状态,得以继续执行。
上面提到的这几点也是
Mutex
的基本原理。
互斥锁使用的两个例子
了解了 go Mutex
基本原理之后,让我们再来看看
Mutex
的一些使用的例子。
gin Context 中的 Set 方法
一个很常见的场景就是,并发对 map
进行读写,熟悉 go
的朋友应该知道,go 中的 map
是不支持并发读写的, 如果我们对
map
进行并发读写会导致 panic
。
而在 gin
的 Context
结构体中,也有一个
map
类型的字段
Keys
,用来在上下文间传递键值对数据, 所以在通过
Set
来设置键值对的时候需要使用 c.mu.Lock()
来先获取互斥锁,然后再对 Keys
做设置。
1 | // Set is used to store a new key/value pair exclusively for this context. |
同样的,对 Keys
做读操作的时候也需要使用互斥锁:
1 | // Get returns the value for the given key, ie: (value, true). |
可能会有人觉得奇怪,为什么从
map
中读也还需要锁。这是因为,如果读的时候没有锁保护, 那么就有可能在Set
设置的过程中,同时也在进行读操作,这样就会panic
了。
这个例子想要说明的是,像 map
这种数据结构本身就不支持并发读写,我们这种情况下只有使用
Mutex
了。
sync.Pool 中的 pinSlow 方法
在 sync.Pool
的实现中,有一个全局变量记录了进程内所有的
sync.Pool
对象,那就是 allPools
变量,
另外有一个锁 allPoolsMu
用来保护对 allPools
的读写操作:
1 | var ( |
pinSlow
方法中会在 allPoolsMu
的保护下对
allPools
做读写操作:
1 | func (p *Pool) pinSlow() (*poolLocal, int) { |
这个例子主要是为了说明使用 mu
的另外一种非常常见的场景:并发读写全局变量。
互斥锁使用的注意事项
互斥锁如果使用不当,可能会导致死锁或者出现 panic
的情况,下面是一些常见的错误:
- 忘记使用
Unlock
释放锁。 Lock
之后还没Unlock
之前又使用Lock
获取锁。也就是重复上锁,go 中的Mutex
不可重入。- 死锁:位于临界区内不同的两个协程都想获取对方持有的不同的锁。
- 还没
Lock
之前就Unlock
。这会导致panic
,因为这是没有任何意义的。 - 复制
Mutex
,比如将Mutex
作为参数传递。
对于第 1 点,我们往往可以使用 defer
关键字来做释放锁的操作。第 2 点不太好发现,只能在开发的时候多加注意。 第
3 点我们在使用锁的时候可以考虑尽量避免在临界区内再去使用别的锁。
最后,Mutex
是不可以复制的,这个可以在编译之前通过
go vet
来做检查。
为什么 Mutex
不能被复制呢?因为 Mutex
中包含了锁的状态,如果复制了,那么这个状态也会被复制, 如果在复制前进行
Lock
,复制后进行 Unlock
,那就意味着
Lock
和 Unlock
操作的其实是两个不同的状态,
这样显然是不行的,是释放不了锁的。
虽然不可以复制,但是我们可以通过传递指针类型的参数来传递
Mutex
。
互斥锁锁定的是什么?
在前一篇文章中,我们提到过,原子操作本质上是变量级的互斥锁。而互斥锁本身锁定的又是什么呢? 其实互斥锁本质上是一个信号量,它通过获取释放信号量,最终使得协程获得某一个代码块的执行权力。
也就是说,互斥锁,锁定的是一块代码块。
我们以 go-zero
里面的 collection/fifo.go
为例子说明一下:
1 | // Take takes the first element out of q if not empty. |
除了锁定代码块的这一个作用,有另外一个比较关键的地方也是我们不能忽视的, 那就是 互斥锁并不保证临界区内操作的变量不能被其他协程访问。 互斥锁只能保证一段代码只能一个协程执行,但是对于临界区内涉及的共享资源, 你在临界区外也依然是可以对其进行读写的。
我们以上面的代码说明一下:在上面的 Take
函数中,我们对
q.head
和 q.count
都进行了操作,
虽然这些操作代码位于临界区内,但是临界区并不保证持有锁期间其他协程不会在临界区外去修改
q.head
和 q.count
。
下面就是一个非常典型的错误的例子:
1 | import ( |
靠谱的做法是,对于有共享资源的读写的操作都使用
Mutex
保护起来。
当然,如果我们只有一个变量,那么可能使用原子操作就足够了。
互斥锁实现原理
互斥锁的实现有以下几个关键的地方:
- 信号量:这是操作系统中的同步对象。
- 等待队列:获取不到互斥锁的协程,会放入到一个先入先出队列的队列尾部。这样信号量释放的时候,可以依次对它们唤醒。
- 原子操作:互斥锁的实现中,使用了一个字段来记录了几种不同的状态,使用原子操作可以保证几种状态可以一次性变更完成。
我们先来看看 Mutex
结构体定义:
1 | type Mutex struct { |
其中 state
字段记录了四种不同的信息:
这四种不同信息在源码中定义了不同的常量:
1 | const ( |
而 sema
的含义比较简单,就是一个用作不同 goroutine
同步的信号量。
信号量
go 的 Mutex
是基于信号量来实现的,那信号量又是什么呢?
维基百科:信号量是一个同步对象,用于保持在
0
至指定最大值之间的一个计数值。当线程完成一次对该semaphore
对象的等待(wait
)时,该计数值减一;当线程完成一次对semaphore
对象的释放(release
)时,计数值加一。
上面这个解释有点难懂,通俗地说,就是一个数字,调用 wait
的时候,这个数字减去 1
,调用 release
的时候,这个数字加上 1
。
(还有一个隐含的逻辑是,如果这个数小于 0
,那么调用
wait
的时候会阻塞,直到它大于 0
。)
对应到 go 的 Mutex
中,有两个操作信号量的函数:
runtime_Semrelease
: 自动递增信号量并通知等待的 goroutine。runtime_SemacquireMutex
: 是一直等到信号量大于 0,然后自动递减。
我们注意到了,其实 runtime_SemacquireMutex
是有一个前提条件的,那就是等到信号量大于 0。 其实信号量的两个操作
P/V
就是一个加 1 一个减
1,所以在实际使用的时候,也是需要一个获取锁的操作对应一个释放锁的操作,
否则,其他协程都无法获取到锁,因为信号量一直不满足。
等待队列
go 中如果已经有 goroutine 持有互斥锁,那么其他的协程会放入一个
FIFO
队列中,如下图:
说明:
G1
表示持有互斥锁的 goroutine,G2
...Gn
表示一个 goroutine 的等待队列,这是一个先入先出的队列。G1
先持有锁,得以进入临界区,其他想抢占锁的 goroutine 阻塞在Lock
调用处。G1
在使用完锁后,会使用Unlock
来释放锁,本质上是释放了信号量,然后会唤醒FIFO
队列头部的goroutine
。G2
从FIFO
队列中移除,进入临界区。G2
使用完锁之后也会使用Unlock
来释放锁。
上面只是一个大概模型,在实际实现中,比这个复杂很多倍,下面会继续深入讲解。
原子操作
go 的 Mutex
实现中,state
字段是一个 32
位的整数,不同的位记录了四种不同信息,在这种情况下,
只需要通过原子操作就可以保证一次性实现对四种不同状态信息的更改,而不需要更多额外的同步机制。
但是毋庸置疑,这种实现会大大降低代码的可读性,因为通过一个整数来记录不同的信息, 就意味着,需要通过各种位运算来实现对这个整数不同位的修改,比如将上锁的操作:
1 | new |= mutexLocked |
当然,这只是 Mutex
实现中最简单的一种位运算了。下面以
state
记录的四种不同信息为维度来具体讲解一下:
mutexLocked
:这是state
的最低位,1
表示锁被占用,0
表示锁没有被占用。new := mutexLocked
新状态为上锁状态
mutexWoken
: 这是表示是否有协程被唤醒了的状态new = (old - 1<<mutexWaiterShift) | mutexWoken
等待者数量减去 1 的同时,设置唤醒标识new &^= mutexWoken
清除唤醒标识
mutexStarving
:饥饿模式的标识new |= mutexStarving
设置饥饿标识
- 等待者数量:
state >> mutexWaiterShift
就是等待者的数量,也就是上面提到的FIFO
队列中 goroutine 的数量new += 1 << mutexWaiterShift
等待者数量加 1delta := int32(mutexLocked - 1<<mutexWaiterShift)
上锁的同时,将等待者数量减 1
这里并没有涵盖
Mutex
中所有的位运算,其他操作在下文讲解源码实现的时候会提到。
在上面做了这一系列的位运算之后,我们会得到一个新的 state
状态,假设名为 new
,那么我们就可以通过 CAS
操作来将 Mutex
的 state
字段更新:
1 | atomic.CompareAndSwapInt32(&m.state, old, new) |
通过上面这个原子操作,我们就可以一次性地更新 Mutex
的
state
字段,也就是一次性更新了四种状态信息。
这种通过一个整数记录不同状态的写法在
sync
包其他的一些地方也有用到,比如WaitGroup
中的state
字段。
最后,对于这种操作,我们需要注意的是,因为我们在执行 CAS
前后是没有其他什么锁或者其他的保护机制的, 这也就意味着上面的这个
CAS
操作是有可能会失败的,那如果失败了怎么办呢?
如果失败了,也就意味着肯定有另外一个 goroutine 率先执行了
CAS
操作并且成功了,将 state
修改为了一个新的值。
这个时候,其实我们前面做的一系列位运算得到的结果实际上已经不对了,在这种情况下,我们需要获取最新的
state
,然后再次计算得到一个新的
state
。
所以我们会在源码里面看到 CAS
操作是写在 for
循环里面的。
Mutex 的公平性
在前面,我们提到 goroutien 获取不到锁的时候,会进入一个
FIFO
队列的队列尾,在实际实现中,其实没有那么简单,
为了获得更好的性能,在实现的时候会尽量先让运行状态的 goroutine
获得锁,当然如果队列中的 goroutine 等待太久(大于 1ms),
那么就会先让队列中的 goroutine 获得锁。
下面是文档中的说明:
Mutex 可以处于两种操作模式:正常模式和饥饿模式。在正常模式下,等待者按照FIFO(先进先出)的顺序排队,但是被唤醒的等待者不拥有互斥锁,会与新到达的 Goroutine 竞争所有权。新到达的 Goroutine 有优势——它们已经在 CPU 上运行,数量可能很多,因此被唤醒的等待者有很大的机会失去锁。在这种情况下,它将排在等待队列的前面。如果等待者未能在1毫秒内获取到互斥锁,则将互斥锁切换到饥饿模式。 在饥饿模式下,互斥锁的所有权直接从解锁 Goroutine 移交给队列前面的等待者。新到达的 Goroutine 即使看起来未被锁定,也不会尝试获取互斥锁,也不会尝试自旋。相反,它们会将自己排队在等待队列的末尾。如果等待者获得互斥锁的所有权并发现(1)它是队列中的最后一个等待者,或者(2)它等待时间少于1毫秒,则将互斥锁切换回正常模式。 正常模式的性能要优于饥饿模式,因为 Goroutine 可以连续多次获取互斥锁,即使有被阻塞的等待者。饥饿模式很重要,可以防止尾部延迟的病态情况。
简单总结:
Mutex
有两种模式:正常模式、饥饿模式。- 正常模式下:
- 被唤醒的 goroutine 和正在运行的 goroutine 竞争锁。这样可以运行中的协程有机会先获取到锁,从而避免了协程切换的开销。性能更好。
- 饥饿模式下:
- 优先让队列中的 goroutine 获得锁,并且直接放弃时间片,让给队列中的 goroutine,运行中的 goroutine 想获取锁要到队尾排队。更加公平。
Mutex 源码剖析
Mutex
本身的源码其实很少,但是复杂程度是非常高的,所以第一次看的时候可能会非常懵逼,但是不妨碍我们去了解它的大概实现原理。
Mutex
中主要有两个方法,Lock
和
Unlock
,使用起来非常的简单,但是它的实现可不简单。下面我们就来深入了解一下它的实现。
Lock
Lock
方法的实现如下:
1 | // Lock 获取锁。 |
在 Lock
方法中,第一次获取锁的时候是非常简单的,一个简单的原子操作设置一下
mutexLocked
标识就完成了。
但是如果这个原子操作失败了,表示有其他 goroutine
先获取到了锁,这个时候就需要调用 lockSlow
来做一些额外的操作了:
1 | // 获取 mutex 锁 |
我们可以看到,lockSlow
的处理非常的复杂,又要考虑让运行中的 goroutine
尽快获取到锁,又要考虑不能让等待队列中的 goroutine 等待太久。
代码中注释很多,再简单总结一下其中的流程:
- 为了让循环中的 goroutine 可以先获取到锁,会先让 goroutine 自旋等待锁的释放,这是因为运行中的 goroutine 正在占用 CPU,让它先获取到锁可以避免一些不必要的协程切换,从而获得更好的性能。
- 自旋完毕之后,会尝试获取锁,同时也要根据旧的锁状态来更新锁的不同状态信息,比如是否进入饥饿模式等。
- 计算得到一个新的
state
后,会进行CAS
操作尝试更新state
状态。 CAS
失败会重试上面的流程。CAS
成功之后会做如下操作:
- 判断当前是否已经获取到锁,如果是,则返回,
Lock
成功了。 - 会判断当前的 goroutine 是否是已经被唤醒过,如果是,会将当前 goroutine 加入到等待队列头部。
- 调用
runtime_SemacquireMutex
,进入阻塞状态,等待下一次唤醒。 - 唤醒之后,判断是否需要进入饥饿模式。
- 最后,如果已经是饥饿模式,当前 goroutine 直接获取到锁,退出循环,否则,再进行下一次抢占锁的循环中。
具体流程我们可以参考一下下面的流程图:
图中有一些矩形方框描述了
unlockSlow
的关键流程。
Unlock
Unlock
方法的实现如下:
1 | // Unlock 释放互斥锁。 |
Unlock
做了两件事:
- 释放当前 goroutine 持有的互斥锁:也就是将
state
减去 1 - 唤醒等待队列中的下一个 goroutine
如果只有一个 goroutine 在使用锁,只需要简单地释放锁就可以了。 但是如果有其他的 goroutine 在阻塞等待,那么持有互斥锁的 goroutine 就有义务去唤醒下一个 goroutine。
唤醒的流程相对复杂一些:
1 | // unlockSlow 唤醒下一个等待锁的协程。 |
unlockSlow
逻辑相比 lockSlow
要简单许多,我们可以再结合下面的流程图来阅读上面的源码:
runtime_Semrelease 第二个参数的含义
细心的朋友可能注意到了,在 unlockSlow
的实现中,有两处地方调用了 runtime_Semrelease
这个方法,
这个方法的作用是释放一个信号量,这样可以让阻塞在信号量上的 goroutine
得以继续执行。 它的第一个参数我们都知道,是信号量,而第二个参数
true
和 false
分别传递了一次, 那么
true
和 false
分别有什么作用呢?
答案是,设置为 true
的时候,当前的 goroutine
会直接放弃自己的时间片, 将 P 的使用权交给 Mutex
等待队列中的第一个 goroutine, 这样的目的是,让 Mutex
等待队列中的 goroutine 可以尽快地获取到锁。
总结
互斥锁在并发编程中也算是非常常见的一种操作了,使用互斥锁可以限制只有一个 goroutine 可以进入临界区, 这对于并发修改全局变量、初始化等情况非常好用。最后,再总结一下本文所讲述的内容:
- 互斥锁是一种用于多线程编程中,防止两个线程同时对同一公共资源进行读写的机制。go
中的互斥锁实现是
sync.Mutex
。 Mutex
的操作只有两个:Lock
获取锁,同一时刻只能有一个 goroutine 可以获取到锁,其他 goroutine 会先通过自旋抢占锁,抢不到则阻塞等待。Unlock
释放锁,释放锁之前必须有 goroutine 持有锁。释放锁之后,会唤醒等待队列中的下一个 goroutine。
Mutex
常见的使用场景有两个:- 并发读写
map
:如gin
中Context
的Keys
属性的读写。 - 并发读写全局变量:如
sync.Pool
中对allPools
的读写。
- 并发读写
- 使用
Mutex
需要注意以下几点:- 不要忘记使用
Unlock
释放锁 Lock
之后,没有释放锁之前,不能再次使用Lock
- 注意不同 goroutine 竞争不同锁的情况,需要考虑一下是否有可能会死锁
- 在
Unlock
之前,必须已经调用了Lock
,否则会panic
- 在第一次使用
Mutex
之后,不能复制,因为这样一来Mutex
的状态也会被复制。这个可以使用go vet
来检查。
- 不要忘记使用
- 互斥锁可以保护一块代码块只能有一个 goroutine 执行,但是不保证临界区内操作的变量不被其他 goroutine 做并发读写操作。
- go 的
Mutex
基于以下技术实现:- 信号量:这是操作系统层面的同步机制
- 队列:在 goroutine 获取不到锁的时候,会将这些 goroutine 放入一个 FIFO 队列中,下次唤醒会唤醒队列头的 goroutine
- 原子操作:
state
字段记录了四种不同的信息,通过原子操作就可以保证数据的完整性
- go
Mutex
的公平性:- 正在运行的 goroutine 如果需要锁的话,尽量让它先获取到锁,可以避免不必要的协程上下文切换。会和被唤醒的 goroutine 一起竞争锁。
- 但是如果等待队列中的 goroutine 超过了 1ms 还没有获取到锁,那么会进入饥饿模式
- go
Mutex
的两种模式:- 正常模式:运行中的 goroutine 有一定机会比等待队列中的 goroutine 先获取到锁,这种模式有更好的性能。
- 饥饿模式:所有后来的 goroutine 都直接进入等待队列,会依次从等待队列头唤醒 goroutine。可以有效避免尾延迟。
- 饥饿模式下,
Unlock
的时候会直接将当前 goroutine 所在 P 的使用权交给等待队列头部的 goroutine,放弃原本属于自己的时间片。